package denominator.cli; import com.google.common.base.CaseFormat; import com.google.common.base.Charsets; import com.google.common.base.Function; import com.google.common.base.Functions; import com.google.common.base.Joiner; import com.google.common.base.Predicate; import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList.Builder; import com.google.common.collect.ImmutableSortedSet; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Ordering; import com.google.common.io.Files; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.InstanceCreator; import com.google.gson.TypeAdapter; import com.google.gson.internal.ConstructorConstructor; import com.google.gson.internal.bind.MapTypeAdapterFactory; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import dagger.ObjectGraph; import dagger.Provides; import denominator.Credentials; import denominator.Credentials.AnonymousCredentials; import denominator.Credentials.ListCredentials; import denominator.Credentials.MapCredentials; import denominator.DNSApiManager; import denominator.Denominator.Version; import denominator.Provider; import denominator.Providers; import denominator.cli.GeoResourceRecordSetCommands.GeoRegionList; import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordAddRegions; import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordSetApplyTTL; import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordSetGet; import denominator.cli.GeoResourceRecordSetCommands.GeoResourceRecordSetList; import denominator.cli.GeoResourceRecordSetCommands.GeoTypeList; import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetAdd; import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetApplyTTL; import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetDelete; import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetGet; import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetList; import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetRemove; import denominator.cli.ResourceRecordSetCommands.ResourceRecordSetReplace; import denominator.cli.ZoneCommands.ZoneAdd; import denominator.cli.ZoneCommands.ZoneDelete; import denominator.cli.ZoneCommands.ZoneList; import denominator.cli.ZoneCommands.ZoneUpdate; import denominator.dynect.DynECTProvider; import denominator.model.Zone; import denominator.ultradns.UltraDNSProvider; import feign.Logger; import feign.Logger.Level; import io.airlift.airline.Cli; import io.airlift.airline.Cli.CliBuilder; import io.airlift.airline.Command; import io.airlift.airline.Help; import io.airlift.airline.Option; import io.airlift.airline.OptionType; import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.provider.X509CertParser; import org.bouncycastle.openssl.PEMKeyPair; import org.bouncycastle.openssl.PEMParser; import org.yaml.snakeyaml.Yaml; import javax.inject.Singleton; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.StringReader; import java.lang.reflect.Type; import java.net.MalformedURLException; import java.net.URL; import java.security.KeyFactory; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import static com.google.common.base.Preconditions.checkArgument; import static denominator.CredentialsConfiguration.credentials; import static java.lang.String.format; public class Denominator { static final TypeToken<Map<String, Object>> token = new TypeToken<Map<String, Object>>() { }; static final TypeAdapter<Map<String, Object>> doubleToInt = new TypeAdapter<Map<String, Object>>() { TypeAdapter<Map<String, Object>> delegate = new MapTypeAdapterFactory(new ConstructorConstructor( Collections.<Type, InstanceCreator<?>>emptyMap()), false).create(new Gson(), token); @Override public void write(JsonWriter out, Map<String, Object> value) throws IOException { delegate.write(out, value); } @Override public Map<String, Object> read(JsonReader in) throws IOException { Map<String, Object> map = delegate.read(in); for (Entry<String, Object> entry : map.entrySet()) { if (entry.getValue() instanceof Double) { entry.setValue(Double.class.cast(entry.getValue()).intValue()); } } return map; } }.nullSafe(); // deals with scenario where gson Object type treats all numbers as doubles. static final Gson json = new GsonBuilder().registerTypeAdapter(token.getType(), doubleToInt).create(); public static void main(String[] args) { CliBuilder<Runnable> builder = Cli.<Runnable>builder("denominator") .withDescription("Denominator: Portable control of DNS clouds") .withDefaultCommand(Help.class) .withCommand(Help.class) .withCommand(PrintVersion.class) .withCommand(ListProviders.class); builder.withGroup("zone") .withDescription("manage zones") .withDefaultCommand(ZoneList.class) .withCommand(ZoneList.class) .withCommand(ZoneAdd.class) .withCommand(ZoneUpdate.class) .withCommand(ZoneDelete.class); builder.withGroup("record") .withDescription("manage resource record sets in a zone") .withDefaultCommand(ResourceRecordSetList.class) .withCommand(ResourceRecordSetList.class) .withCommand(ResourceRecordSetGet.class) .withCommand(ResourceRecordSetAdd.class) .withCommand(ResourceRecordSetApplyTTL.class) .withCommand(ResourceRecordSetReplace.class) .withCommand(ResourceRecordSetRemove.class) .withCommand(ResourceRecordSetDelete.class); builder.withGroup("geo") .withDescription("manage geo resource record sets in a zone") .withDefaultCommand(GeoResourceRecordSetList.class) .withCommand(GeoTypeList.class) .withCommand(GeoRegionList.class) .withCommand(GeoResourceRecordSetList.class) .withCommand(GeoResourceRecordSetGet.class) .withCommand(GeoResourceRecordSetApplyTTL.class) .withCommand(GeoResourceRecordAddRegions.class); Cli<Runnable> denominatorParser = builder.build(); try { denominatorParser.parse(args).run(); } catch (RuntimeException e) { if (e instanceof NullPointerException) { e.printStackTrace(); } System.err.println(";; error: " + e.getMessage()); System.exit(1); } System.exit(0); } /** * Returns a log configuration module or null if none is needed. */ static Object logModule(boolean quiet, boolean verbose) { checkArgument(!(quiet && verbose), "quiet and verbose flags cannot be used at the same time!"); Logger.Level logLevel; if (quiet) { return null; } else if (verbose) { logLevel = Logger.Level.FULL; } else { logLevel = Logger.Level.BASIC; } return new LogModule(logLevel); } static String id(DNSApiManager mgr, String zoneIdOrName) { if (zoneIdOrName.indexOf('.') == -1) { // Assume that ids don't have dots in them! return zoneIdOrName; } if (zoneNameIsId(mgr.provider())) { return zoneIdOrName; } Iterator<Zone> result = mgr.api().zones().iterateByName(zoneIdOrName); checkArgument(result.hasNext(), "zone %s not found", zoneIdOrName); return result.next().id(); } // Special-case providers known to use zone names as ids, as this usually saves 1-200ms of // lookups. We can later introduce a flag or other means to help third-party providers. static boolean zoneNameIsId(Provider provider) { return provider instanceof UltraDNSProvider || provider instanceof DynECTProvider; } @Command(name = "version", description = "output the version of denominator and java runtime in use") public static class PrintVersion implements Runnable { public void run() { System.out.println("Denominator " + Version.INSTANCE); System.out.println("Java version: " + System.getProperty("java.version")); } } @Command(name = "providers", description = "List the providers and their metadata ") public static class ListProviders implements Runnable { final static String table = "%-10s %-51s %-14s %-14s %s%n"; public static String providerAndCredentialsTable() { StringBuilder builder = new StringBuilder(); builder.append(format( table, "provider", "url", "duplicateZones", "credentialType", "credentialArgs")); for (Provider p : ImmutableSortedSet.copyOf(Ordering.usingToString(), Providers.list())) { if (p.credentialTypeToParameterNames().isEmpty()) { builder.append( format("%-10s %-51s %-14s %n", p.name(), p.url(), p.supportsDuplicateZoneNames())); } for (Entry<String, Collection<String>> e : p.credentialTypeToParameterNames().entrySet()) { String params = Joiner.on(' ').join(e.getValue()); builder.append(format( table, p.name(), p.url(), p.supportsDuplicateZoneNames(), e.getKey(), params)); } } return builder.toString(); } public void run() { System.out.println(providerAndCredentialsTable()); } } public static abstract class DenominatorCommand implements Runnable { private static final String ENV_PREFIX = "DENOMINATOR_"; @Option(type = OptionType.GLOBAL, name = {"-q", "--quiet"}, description = "do not emit informational messages about http commands invoked") public boolean quiet; @Option(type = OptionType.GLOBAL, name = {"-v", "--verbose"}, description = "emit details such as http requests sent and responses received") public boolean verbose; @Option(type = OptionType.GLOBAL, name = {"-p", "--provider"}, description = "provider to affect") public String providerName; @Option(type = OptionType.GLOBAL, name = {"-u", "--url"}, description = "alternative api url to connect to") public String url; @Option(type = OptionType.GLOBAL, name = {"-c", "--credential"}, description = "adds a credential argument (execute denominator providers for what these are)") public List<String> credentialArgs; @Option(type = OptionType.GLOBAL, name = {"-C", "--config"}, description = "path to configuration file (used to store credentials). default: ~/.denominatorconfig") public String configPath = "~/.denominatorconfig"; @Option(type = OptionType.GLOBAL, name = {"-n", "--configuration-name"}, description = "unique name of provider configuration") public String providerConfigurationName; protected Credentials credentials = AnonymousCredentials.INSTANCE; @SuppressWarnings("unchecked") public void run() { setProxyFromEnv(); if (providerName != null && credentialArgs != null) { credentials = ListCredentials.from(Lists.transform(credentialArgs, decodeAnyPems)); } else if (providerConfigurationName != null) { Map<?, ?> configFromFile = getConfigFromFile(); if (configFromFile != null) { credentials = MapCredentials.from( Maps.transformValues(Map.class.cast(configFromFile.get("credentials")), decodeAnyPems)); providerName = configFromFile.get("provider").toString(); if (configFromFile.containsKey("url")) { url = configFromFile.get("url").toString(); } } } else { overrideFromEnv(System.getenv()); } Provider provider = Providers.getByName(providerName); if (url != null) { provider = Providers.withUrl(provider, url); } Builder<Object> modulesForGraph = ImmutableList.builder() // .add(Providers.provide(provider)) // .add(Providers.instantiateModule(provider)); Object logModule = logModule(quiet, verbose); if (logModule != null) { modulesForGraph.add(logModule); } if (credentials != AnonymousCredentials.INSTANCE) { modulesForGraph.add(credentials(credentials)); } DNSApiManager mgr = null; try { mgr = ObjectGraph.create(modulesForGraph.build().toArray()).get(DNSApiManager.class); for (Iterator<String> i = doRun(mgr); i.hasNext(); ) { System.out.println(i.next()); } } finally { if (mgr != null) { try { mgr.close(); } catch (IOException ignored) { } } } } private static final Function<Object, Object> maybeDecodeX509Pem = new Function<Object, Object>() { @Override public Object apply(Object input) { if (input instanceof String && input.toString().contains("BEGIN CERTIFICATE")) { try { X509CertParser x509CertParser = new X509CertParser(); x509CertParser.engineInit(new ByteArrayInputStream(input.toString().getBytes())); return x509CertParser.engineRead(); } catch (Exception ex) { return input; } } return input; } }; private static final Function<Object, Object> maybeDecodePrivateKeyPem = new Function<Object, Object>() { @Override public Object apply(Object input) { if (input instanceof String && input.toString().contains("BEGIN RSA PRIVATE KEY")) { try { PEMKeyPair pemKeyPair = (PEMKeyPair) new PEMParser(new StringReader(input.toString())).readObject(); PrivateKeyInfo privateKeyInfo = pemKeyPair.getPrivateKeyInfo(); KeyFactory keyFact = KeyFactory.getInstance( privateKeyInfo.getPrivateKeyAlgorithm().getAlgorithm().getId(), new BouncyCastleProvider()); return keyFact.generatePrivate(new PKCS8EncodedKeySpec(privateKeyInfo.getEncoded())); } catch (Exception ex) { return input; } } return input; } }; private static final Function<Object, Object> decodeAnyPems = Functions.compose(maybeDecodeX509Pem, maybeDecodePrivateKeyPem); /** * Load configuration for given providerConfigurationName from a YAML configuration file. */ Map<?, ?> getConfigFromFile() { if (configPath == null) { return null; } String configFileContent = null; try { configFileContent = getFileContentsFromPath(configPath); } catch (IOException e) { System.err.println("configuration file not found: " + e.getMessage()); System.exit(1); } return getConfigFromYaml(configFileContent); } Map<?, ?> getConfigFromYaml(String yamlAsString) { Yaml yaml = new Yaml(); Iterable<Object> configs = yaml.loadAll(yamlAsString); Object providerConf = FluentIterable.from(configs).firstMatch(new Predicate<Object>() { @Override public boolean apply(Object input) { return providerConfigurationName.equals(Map.class.cast(input).get("name")); } }).get(); return Map.class.cast(providerConf); } String getFileContentsFromPath(String path) throws IOException { if (path.startsWith("~")) { path = System.getProperty("user.home") + path.substring(1); } return Files.toString(new File(path), Charsets.UTF_8); } void overrideFromEnv(Map<String, String> env) { if (providerName == null) { providerName = env.get(ENV_PREFIX + "PROVIDER"); } if (url == null) { url = env.get(ENV_PREFIX + "URL"); } Provider providerLoaded = Providers.getByName(providerName); if (providerLoaded != null) { Map<String, String> credentialMap = new LinkedHashMap<String, String>(); // merge the list of possible credentials for (Entry<String, Collection<String>> entry : providerLoaded.credentialTypeToParameterNames().entrySet()) { for (String paramName : entry.getValue()) { String upperParamName = CaseFormat.LOWER_CAMEL.to(CaseFormat.UPPER_UNDERSCORE, paramName); String value = env.get(ENV_PREFIX + upperParamName); if (value != null) { credentialMap.put(paramName, value); } } } if (!credentialMap.isEmpty()) { credentials = MapCredentials.from(credentialMap); } } } static void setProxyFromEnv() { setProtocolProxyFromEnv("http", System.getenv("HTTP_PROXY")); setProtocolProxyFromEnv("https", System.getenv("HTTPS_PROXY")); } static void setProtocolProxyFromEnv(String proto, String envProxy) { if (envProxy != null && !envProxy.isEmpty()) { try { URL proxyUrl = new URL(envProxy); String proxyHost = System.getProperty(proto + ".proxyHost"); if ((proxyHost == null || proxyHost.isEmpty())) { System.setProperty(proto + ".proxyHost", proxyUrl.getHost()); System.setProperty(proto + ".proxyPort", Integer.toString( proxyUrl.getPort() == -1 ? proxyUrl.getDefaultPort() : proxyUrl.getPort())); } } catch (MalformedURLException e) { System.err.println("invalid " + proto + " proxy configuration: " + e.getMessage()); System.exit(1); } } } /** * return a lazy iterator where possible to improve the perceived responsiveness of the cli */ protected abstract Iterator<String> doRun(DNSApiManager mgr); } @dagger.Module(overrides = true, library = true) static class LogModule { final Logger.Level logLevel; LogModule(Level logLevel) { this.logLevel = logLevel; } @Provides @Singleton Logger logger() { return new Logger.ErrorLogger(); } @Provides @Singleton Logger.Level level() { return logLevel; } } }